Autentikasi, pertanyaan siapa Anda.
Penelusuran dari prinsip dasar tentang dua gagasan yang disentuh setiap backend tiap hari, siapa Anda (autentikasi) dan apa yang bisa Anda lakukan (otorisasi). Dari segel lilin dan rahasia bersama sampai sesi, JWT, cookie, OAuth 2.0, OpenID Connect, dan RBAC. Ditulis bukan hanya untuk menyebut apa fungsi tiap bagian, tapi mengapa ia ada dan bagaimana ia bekerja di dalamnya. Ada implementasi di Go dan Python.
01 Ringkasan Dua Kalimat
Seluruh topik menyusut menjadi dua pertanyaan. Autentikasi adalah mekanisme menetapkan identitas pada subjek di dalam konteks tertentu; ia menjawab pertanyaan "siapa Anda?". Konteksnya bisa platform, sistem operasi, ponsel Anda, apa pun. Proses menemukan jawaban pertanyaan itu adalah autentikasi.
Otorisasi menjawab pertanyaan yang sama sekali berbeda: "apa yang bisa Anda lakukan?" dalam konteks itu. Kemampuan Anda, izin Anda. Proses menemukan jawaban apa yang bisa Anda lakukan di sini adalah otorisasi. Autentikasi menetapkan identitas lebih dulu; otorisasi memutuskan apa yang boleh dilakukan identitas itu setelahnya.
02 Sejarah Autentikasi
Auth modern tidak muncul dari ketiadaan, setiap mekanisme yang kita pakai hari ini adalah keturunan gagasan yang jauh lebih tua. Menelusuri garis keturunannya membuat kompromi desainnya tampak jelas.
Masyarakat pra-industri: kepercayaan intrinsik
Autentikasi bersifat implisit. Identitas subjek sama dengan pengakuan subjek: seseorang yang sudah dihormati komunitas, tetua desa, bisa menjamin seseorang, dan kesepakatan dimeteraikan dengan jabat tangan, tindakan yang melambangkan pengakuan dan kesepakatan bersama. Elementer, tapi ia memakai bentuk autentikasi yang dibangun di atas kepercayaan kontekstual manusia. Saat populasi tumbuh dan interaksi melampaui lingkaran yang dikenal, kepercayaan implisit gagal berskala, tetua satu desa tidak dipercaya lintas negara atau benua. Ini menandai pencarian autentikasi eksplisit: bukti identitas yang berfungsi tanpa kenalan pribadi.
Abad pertengahan: segel & era kriptografis
Masyarakat butuh sistem yang bisa berskala melampaui pengenalan pribadi. Jawabannya segel lilin: pola unik yang ditekan ke dokumen dan perjanjian, bertindak sebagai tanda tangan awal. Segel adalah token autentikasi pertama yang diadopsi luas: representasi fisik identitas yang mengandalkan kepemilikan ("sesuatu yang Anda punya"). Jika Anda memegang segel, Anda terautentikasi. Tapi segel bisa dipalsukan, menandai kasus tercatat pertama serangan bypass autentikasi: melewati autentikasi dengan niat jahat. Ini mendorong mekanisme yang lebih canggih: watermark dan kode terenkripsi di dokumentasi dagang, meletakkan fondasi pemikiran kriptografis.
Revolusi industri: passphrase & rahasia bersama
Saat mesin mengubah dunia, sistem komunikasi ikut berevolusi. Telegraf menjadi infrastruktur kritis, dan bersamanya muncul kebutuhan validasi pesan yang aman. Operator memakai passphrase yang disepakati di awal: bentuk awal rahasia bersama, praktisnya kata sandi statis. Prinsipnya bergeser dari "sesuatu yang Anda miliki" (segel) ke "sesuatu yang Anda ketahui", lompatan keamanan yang berarti dan leluhur langsung kata sandi modern.
Mainframe: fase digital pertama
Pada 1961, peneliti di Project MAC MIT menggarap Compatible Time-Sharing System (CTSS) dan memperkenalkan kata sandi untuk sistem multi-pengguna, supaya banyak pengguna bisa berbagi satu komputer tanpa saling membocorkan data. Kata sandi disimpan sebagai teks polos: kerentanan kritis, yang jadi terlihat pada hari seseorang mencetak file kata sandi. Insiden itu menandai kelahiran penyimpanan kata sandi yang aman dan memotivasi filosofi yang masih kita pegang: jangan pernah menyimpan kata sandi sebagai teks polos. Ia berujung pada hashing.
Algoritma hashing menerima string teks polos dengan panjang apa pun lalu menjalankan komputasi satu arah yang mengembalikan string berpanjang tetap. Panjangnya tidak pernah berubah entah Anda memberi 3 atau 100 karakter, dan input yang sama selalu menghasilkan output yang sama. Ia tidak bisa dibalik, Anda tidak bisa memulihkan aslinya dari hash, persis itulah kenapa yang disimpan adalah hash, bukan kata sandi.
1970-an: kriptografi asimetris
Ledakan riset kriptografi, didorong Whitfield Diffie dan Martin Hellman, menghasilkan pertukaran key Diffie-Hellman dan konsep kriptografi asimetris: membiarkan dua pihak membangun rahasia bersama lewat medium yang tidak tepercaya. Kripto asimetris menjadi tulang punggung protokol auth modern (juga disebut PKI: public key infrastructure). Era ini juga memberi Kerberos, yang memperkenalkan autentikasi berbasis tiket memakai pihak ketiga tepercaya untuk menerbitkan tiket yang memverifikasi pengguna dan layanan, pendahulu langsung sistem berbasis token zaman sekarang.
1990-an: MFA & biometrik
Saat internet tumbuh, sistem username/password sederhana tidak cukup kuat melawan brute-force dan serangan kamus. Ini melahirkan Multi-Factor Authentication (MFA), yang menggabungkan tiga kategori bukti:
Autentikasi biometrik muncul di sini, memakai pengenalan pola dan model statistik untuk mengidentifikasi pengguna lewat ciri fisik unik. Tapi biometrik membawa tantangannya sendiri: false positive/negative dan keamanan template: jadi ia tidak pernah menjadi solusi satu langkah untuk semua masalah.
Abad ke-21: dan masa depan
Cloud computing, perangkat mobile, dan arsitektur berbasis API menuntut kerangka yang lebih maju. Tak satu pun metode lama cukup sendirian, dan dari sanalah lahir komponen yang masih kita pakai: OAuth dan OAuth 2.0, JWT, arsitektur zero-trust, dan sistem passwordless seperti WebAuthn (mengandalkan public/private key yang disimpan di perangkat keras). Ke depan, kandidat masa depannya adalah identitas terdesentralisasi (dibangun di atas blockchain), biometrik perilaku, dan kriptografi pasca-kuantum.
Begitu komputer kuantum umum, mereka cukup cepat untuk memecahkan algoritma (RSA dan skema public/private-key lain) yang hari ini membuat data kita terasa aman; algoritma itu memang tidak pernah dirancang untuk daya komputasi setinggi itu. Kriptografi pasca-kuantum adalah keluarga teknik yang dirancang tetap aman bahkan melawan mesin kuantum. Beberapa algoritma semacam itu sudah ada.
03 Tiga Komponen Inti
Sebelum bagian teknis, tiga komponen muncul berulang di setiap alur auth dan authz. Mengenalinya lebih dulu memberi Anda kosakata untuk semua yang menyusul: sesi, JWT, dan cookie.
- Sesi: konteks sementara di sisi server yang memberi web stateless sedikit memori tentang pengguna di antara request.
- JWT (JSON Web Token): token bertanda tangan yang mandiri, membawa klaim pengguna sehingga server tidak perlu state tersimpan untuk memverifikasinya.
- Cookie: mekanisme penyimpanan sisi browser yang dikendalikan server, otomatis menempel di setiap request berikutnya.
Tiga bagian berikutnya membahas masing-masing secara bergiliran, secara mendalam.
04 Sesi
Saat web lahir, semua komunikasi klien-server berjalan di atas HTTP, dan sejak desain HTTP stateless: setiap request diperlakukan sebagai interaksi terisolasi. Server tidak mengingat request Anda sebelumnya; tiap request harus membawa semua informasi yang dibutuhkan server untuk menjalankan logika bisnisnya. HTTP tidak punya memori tentang pertukaran masa lalu.
Itu ideal untuk web awal, kebanyakan halaman statis, gambar statis, data yang bisa dibaca. Tidak ada yang butuh kontinuitas antar request. Tapi saat web beralih ke konten dinamis, statelessness menjadi hambatan. Situs e-commerce harus mengingat isi keranjang Anda; pengguna harus tetap login saat berpindah antar halaman. Kebutuhan inilah yang menandai dimulainya interaksi ber-state, dan sesi adalah jawabannya, cara membangun konteks sisi server yang sementara untuk tiap pengguna.
Cara kerja sesi
- Pembuatan sesi. Saat pengguna login, server membuat session ID unik dan menyimpannya bersama data pengguna yang relevan (role, nama, isi keranjang, apakah pengguna terautentikasi) di penyimpanan persisten: database, atau store di memori seperti Redis.
- Pengiriman. Session ID, cuma sebuah key, dikirim ke klien (browser) sebagai cookie. Setiap request berikutnya menyertakan cookie itu, jadi server bisa memakai session ID untuk mengambil semua data pengguna kembali dari store.
- Kedaluwarsa. Sesi berumur pendek. Jika kedaluwarsanya 15 menit, setelah itu sesi tidak valid; server membuat sesi baru dan menerbitkan session ID baru. Lingkaran ini berulang.
Evolusi penyimpanannya
Penyimpanan sesi matang dalam tiga tahap, masing-masing memecahkan masalah skalabilitas tahap sebelumnya:
- Sesi berbasis file: pendekatan paling awal, menyimpan data dalam file di server. Sederhana, tapi menderita masalah skalabilitas saat jumlah pengguna bertambah.
- Sesi berbasis database: memindahkan data sesi ke database untuk pencarian lebih cepat dan ketahanan di atas restart server (sesi selamat dari restart).
- Store terdistribusi: akhirnya data sesi pindah ke store terdistribusi dalam memori seperti Redis atau Memcached (data di RAM, bukan disk), yang jauh lebih cepat daripada pencarian database.
Kita masih memakai sesi sampai hari ini, untuk tujuan yang sama persis: memberi server sedikit memori tentang pengguna.
Kode: hashing kata sandi & login sesi
Login sesi pertama memverifikasi kredensial terhadap hash tersimpan (tak pernah teks polos), lalu mencetak session ID acak dan menyimpan payload pengguna di Redis dengan TTL.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/crypto/bcrypt"
)
var rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
var ctx = context.Background()
type User struct {
ID string `json:"id"`
Role string `json:"role"`
}
// hash once at signup; store hash, never the password
func HashPassword(pw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(b), err
}
// bcrypt.CompareHashAndPassword is constant-time internally
func CheckPassword(hash, pw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
func newSessionID() string {
b := make([]byte, 32)
rand.Read(b) // cryptographically random
return hex.EncodeToString(b)
}
func Login(w http.ResponseWriter, u User) error {
sid := newSessionID()
data, _ := json.Marshal(u)
// store {sessionID -> userData} with a 15-minute TTL
if err := rdb.Set(ctx, "sess:"+sid, data, 15*time.Minute).Err(); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: "sid",
Value: sid,
HttpOnly: true, // JS cannot read it
Secure: true, // HTTPS only
SameSite: http.SameSiteLaxMode,
Path: "/",
Expires: time.Now().Add(15 * time.Minute),
})
return nil
}
import json, secrets
import bcrypt
import redis
from flask import Flask, request, make_response
app = Flask(__name__)
rdb = redis.Redis(host="localhost", port=6379)
TTL = 15 * 60 # 15 minutes
# hash once at signup; store hash, never the password
def hash_password(pw: str) -> bytes:
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt())
# bcrypt.checkpw is constant-time internally
def check_password(hash_: bytes, pw: str) -> bool:
return bcrypt.checkpw(pw.encode(), hash_)
def new_session_id() -> str:
return secrets.token_hex(32) # cryptographically random
@app.post("/login")
def login():
body = request.get_json()
# ... look up user, check_password(stored_hash, body["password"]) ...
user = {"id": "u_42", "role": "admin"}
sid = new_session_id()
# store {sessionID -> userData} with a 15-minute TTL
rdb.setex(f"sess:{sid}", TTL, json.dumps(user))
resp = make_response({"ok": True})
resp.set_cookie(
"sid", sid,
httponly=True, # JS cannot read it
secure=True, # HTTPS only
samesite="Lax",
max_age=TTL,
path="/",
)
return resp
Selami: cara menyimpan kata sandi yang benar
"Simpan hash-nya, bukan kata sandinya" hanya setengah aturan. Hash yang mana, dan apa yang Anda campurkan ke dalamnya, menentukan apakah database yang bocor adalah insiden kecil atau bencana.
Kenapa hash serbaguna adalah alat yang salah
SHA-256 dan MD5 dibuat untuk cepat: persis yang tidak Anda inginkan untuk kata sandi. GPU atau ASIC modern menghitung miliaran hash SHA-256 per detik, jadi tabel hash cepat yang dicuri bisa di-brute-force hampir gratis. Penyimpanan kata sandi butuh fungsi hashing kata sandi (KDF lambat yang bisa disetel) yang sengaja dibuat mahal dan, idealnya, memory-hard supaya penyerang tidak bisa memparalelkan murah di GPU. Pilihan yang diterima adalah Argon2id (preferensi saat ini), scrypt, dan bcrypt.
Salt: mengalahkan prekomputasi
Salt adalah nilai acak unik yang dibuat per kata sandi dan disimpan sebagai teks polos tepat di samping hash. Ia melakukan dua hal: kata sandi identik tidak lagi menghasilkan hash identik (jadi penyerang tidak bisa melihat kata sandi yang dipakai ulang atau memecahkan banyak sekaligus), dan rainbow table hasil prekomputasi jadi sia-sia karena penyerang butuh satu tabel terpisah per salt. Salt bukan rahasia: satu-satunya tugasnya unik.
Pepper: rahasia yang tidak pernah dipegang database
Pepper adalah satu rahasia tingkat situs yang disimpan di luar database, di environment variable, secret manager, atau HSM. Anda HMAC kata sandi dengan pepper sebelum memberinya ke KDF. Hasilnya: jika hanya database yang bocor (salt + hash) tapi rahasia aplikasi tidak, hash-nya tetap tak terpecahkan, karena penyerang kekurangan pepper. Salt melindungi dari prekomputasi; pepper melindungi dari breach yang hanya menyentuh DB.
Work factor / biaya
Setiap KDF mengekspos parameter biaya yang Anda setel ke perangkat keras: biaya bcrypt (putaran logaritmik), N/r/p scrypt, dan time, memory, serta parallelism Argon2. Pilih nilai supaya satu hash memakan kira-kira 250 ms di server Anda, cukup lambat untuk membatasi penyerang, cukup cepat untuk login sungguhan, dan naikkan seiring waktu begitu perangkat keras membaik. Parameter terpilih disimpan di dalam string hash sehingga Anda bisa memverifikasi hash lama dan men-hash ulang secara transparan saat login berikutnya ketika biaya dinaikkan.
| KDF | Memory-hard? | Catatan |
|---|---|---|
| Argon2id | Ya | Bawaan pilihan; menahan GPU/ASIC; setel memory + time + parallelism. |
| scrypt | Ya | Pilihan memory-hard yang baik; setel N/r/p. |
| bcrypt | Tidak | Masih bisa diterima; membatasi input di 72 byte, pre-hash input panjang. |
| SHA-256 / MD5 | Tidak | Jangan untuk kata sandi, terlalu cepat. |
Kode: Argon2id dengan pepper
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"golang.org/x/crypto/argon2"
)
var pepper = []byte(os.Getenv("PASSWORD_PEPPER")) // secret, NOT in DB
// cost parameters, tune so one hash ~250ms
const (
aTime = 3 // iterations
aMemory = 64 * 1024 // 64 MB, memory-hard
aThreads = 4
aKeyLen = 32
)
func peppered(pw string) []byte {
m := hmac.New(sha256.New, pepper)
m.Write([]byte(pw))
return m.Sum(nil)
}
// returns an encoded string: salt + params + hash
func HashArgon(pw string) string {
salt := make([]byte, 16)
rand.Read(salt) // unique per password
h := argon2.IDKey(peppered(pw), salt, aTime, aMemory, aThreads, aKeyLen)
return fmt.Sprintf("argon2id$%d$%d$%d$%s$%s", aTime, aMemory, aThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(h))
}
// re-derive with the SAME salt+params, then constant-time compare
func VerifyArgon(encoded, pw string) bool {
var t, mem, th uint32
var b64salt, b64hash string
fmt.Sscanf(encoded, "argon2id$%d$%d$%d$%s$%s", &t, &mem, &th, &b64salt, &b64hash)
salt, _ := base64.RawStdEncoding.DecodeString(b64salt)
want, _ := base64.RawStdEncoding.DecodeString(b64hash)
got := argon2.IDKey(peppered(pw), salt, t, mem, uint8(th), uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
import os, hmac, hashlib
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
PEPPER = os.environ["PASSWORD_PEPPER"].encode() # secret, NOT in DB
# cost parameters, tune so one hash ~250ms
ph = PasswordHasher(
time_cost=3, # iterations
memory_cost=64 * 1024, # 64 MB, memory-hard
parallelism=4,
hash_len=32,
salt_len=16, # random salt baked into the output
)
def peppered(pw: str) -> bytes:
return hmac.new(PEPPER, pw.encode(), hashlib.sha256).digest()
# argon2-cffi embeds salt + params inside the returned string
def hash_argon(pw: str) -> str:
return ph.hash(peppered(pw))
def verify_argon(encoded: str, pw: str) -> bool:
try:
ph.verify(encoded, peppered(pw)) # constant-time internally
except VerifyMismatchError:
return False
# transparently upgrade old hashes when cost is raised
if ph.check_needs_rehash(encoded):
rehash_and_store(hash_argon(pw))
return True
05 JSON Web Token (JWT)
Menjelang pertengahan 2000-an, aplikasi web telah tumbuh menjadi sistem terdistribusi global, dan sistem ber-state (sesi), meski efektif, menimbulkan hambatan. Tiga masalah mendorong industri ke pendekatan baru:
- Memori. Menjaga data sesi untuk jutaan pengguna menjadi biaya yang mahal.
- Replikasi. Dalam arsitektur terdistribusi, menyinkronkan data sesi lintas server dan region (satu server di satu belahan dunia, yang lain di sisi berlawanan) memperkenalkan latensi dalam alur auth.
- Konsistensi. Menjaga state yang tersinkron itu tetap benar di mana-mana itu sulit.
Developer ingin cara menggeser state keluar dari server sambil menjaga keamanan dan integritas. Jawabannya, diformalkan pada 2015, adalah JWT: mekanisme stateless untuk memindahkan klaim antara dua pihak. Inovasi kuncinya: token yang mandiri. Sebuah JWT membawa data pengguna (user ID, role) dan tanda tangan kriptografis di dalam token itu sendiri, semuanya di-encode Base64.
Anatomi: tiga bagian
JWT adalah satu string ber-encode Base64URL dengan tiga bagian yang dipisah titik: header.payload.signature.
Header memegang metadata seperti algoritma penandatanganan. Payload memegang klaim: sub adalah konvensi untuk user ID (dari DB atau auth provider Anda, konteks apa pun), iat berarti "issued at", plus field opsional seperti name dan role (admin / member / editor / viewer). Signature dihitung dengan secret key yang hanya Anda pegang. Jika ada yang mengubah JWT, verifikasi dengan secret Anda gagal, Anda langsung tahu token itu tidak lagi valid. Begitulah cara memverifikasi data pengguna secara stateless: tanpa pencarian sesi, cukup cek tanda tangan di tiap request. Ringan dan menghemat biaya penyimpanan.
Kelebihannya
- Stateless: tanpa biaya penyimpanan sisi server.
- Skalabilitas: dalam arsitektur microservice dan terdistribusi, cookie yang diterbitkan satu server tidak bisa dipakai domain lain, tapi JWT bisa dikirim ke banyak server; masing-masing memegang secret bersama, men-decode token, dan memverifikasi identitas. Server berskala ke banyak instance dan semuanya mengautentikasi serentak memakai secret bersama.
- Portabilitas: ringan, ramah URL, dan ber-encode Base64, JWT mudah bepergian: di header, di cookie, bahkan di nilai URL (dan di local storage, meski umumnya sebaiknya jangan).
Tantangannya
Karena JWT stateless, server tidak punya catatan tersimpan untuk divalidasi. Jika seseorang mendapatkan JWT Anda, mereka bisa menyamar sebagai Anda dan bertindak atas nama Anda sampai token kedaluwarsa, dan tidak ada cara bawaan untuk mencabutnya lebih awal kecuali server mengganti secret-nya. Tapi memutar secret mencabut token semua pengguna, memaksa semua orang login ulang hanya untuk mematikan satu token.
Untuk alasan yang sama, Anda tidak bisa mencabut akses satu pengguna sesuai permintaan. Karena JWT stateless, tidak ada cara melacak status token sampai ia kedaluwarsa.
Pendekatan hibrida (dan paradoksnya)
Untuk mengalahkan kedua masalah itu, tim menggabungkan statelessness dengan sedikit statefulness. Pengguna login, mendapat JWT, dan mengirimnya di tiap request (biasanya di header Authorization). Server memverifikasi tanda tangan dengan secret-nya, tanpa pencarian penyimpanan. Lalu, setelah verifikasi lewat, server memeriksa blacklist JWT yang dicabut yang disimpan di store persisten (Redis/DB). Ini memungkinkan Anda memblokir atau mencabut pengguna untuk sementara (mis. akun yang diretas) tanpa memutar secret global.
Seluruh alasan JWT adalah statelessness. Jika Anda toh kini melakukan pencarian store persisten untuk memeriksa validitas, kenapa tidak langsung memakai pendekatan ber-state sejak awal, yang umumnya dianggap lebih aman dan memberi kendali ini gratis? Ini perdebatan industri yang sering terjadi.
Untuk sistem menengah-ke-besar, jangan bersusah payah soal hashing, salting, algoritma, dan strategi pencabutan sendiri, pakai auth provider (Auth0, Clerk, dan semacamnya). Tugas merekalah memikirkan komponen mana yang paling aman. Latihan yang bagus untuk mengimplementasikan auth sendiri saat belajar backend, demi memahami bagian-bagian dan komprominya, tapi di produksi, ambil provider kecuali Anda sangat yakin dengan alur auth Anda.
Kode: menandatangani & memverifikasi JWT
package auth
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("keep-this-very-secret")
// mint a self-contained token carrying the claims
func Sign(userID, role string) (string, error) {
claims := jwt.MapClaims{
"sub": userID, // user id
"role": role, // for authorization
"iat": time.Now().Unix(), // issued at
"exp": time.Now().Add(time.Hour).Unix(), // expiry
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return tok.SignedString(secret)
}
// verify signature + expiry; returns the claims if valid
func Verify(tokenStr string) (jwt.MapClaims, error) {
tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
// pin the algorithm to stop "alg: none" attacks
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return secret, nil
})
if err != nil || !tok.Valid {
return nil, err
}
return tok.Claims.(jwt.MapClaims), nil
}
import jwt # PyJWT
from datetime import datetime, timedelta, timezone
SECRET = "keep-this-very-secret"
# mint a self-contained token carrying the claims
def sign(user_id: str, role: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id, # user id
"role": role, # for authorization
"iat": now, # issued at
"exp": now + timedelta(hours=1) # expiry
}
return jwt.encode(payload, SECRET, algorithm="HS256")
# verify signature + expiry; returns the claims if valid
def verify(token: str) -> dict:
# algorithms is a whitelist -> stops "alg: none" attacks
return jwt.decode(token, SECRET, algorithms=["HS256"])
# hybrid: after verify(), also check a revocation blacklist
def is_revoked(rdb, jti_or_token: str) -> bool:
return rdb.sismember("jwt:blacklist", jti_or_token)
Selami: penandatanganan, serangan, dan refresh token
HS256 vs RS256: penandatanganan simetris vs asimetris
Alg di header menentukan siapa yang bisa membuat token valid. Dengan HS256 (HMAC) ada satu secret bersama yang dipakai untuk menandatangani dan memverifikasi, jadi setiap layanan yang bisa memverifikasi juga bisa memalsukan. Itu baik di dalam satu batas kepercayaan, berbahaya lintas banyak. Dengan RS256 / ES256 (asimetris), server auth menandatangani dengan private key dan yang lain memverifikasi dengan public key. Verifier tidak bisa mencetak token, dan Anda bisa memublikasikan public key (endpoint JWKS) sehingga microservice yang tak berhubungan dan pihak ketiga memvalidasi secara independen. Inilah alasan sebenarnya JWT berskala lintas sistem terdistribusi: verifikasi tidak butuh berbagi kekuatan penandatanganan.
| HS256 (simetris) | RS256 / ES256 (asimetris) | |
|---|---|---|
| Key | Satu secret bersama | Private (tanda tangan) + public (verifikasi) |
| Siapa yang bisa memalsukan | Siapa pun yang bisa memverifikasi | Hanya pemegang private key |
| Terbaik untuk | Satu layanan / monolit | Microservice, verifier pihak ketiga, OIDC |
Serangan alg:none dan key-confusion
Dua kerentanan JWT klasik sama-sama berasal dari mempercayai header token itu sendiri. Pada serangan alg:none, penyerang meng-set "alg":"none" dan membuang signature; verifier naif yang "menuruti kata header" menerima token tanpa tanda tangan. Pada serangan key-confusion RS256->HS256, penyerang mengganti algoritma menjadi HS256 dan menandatangani dengan public key (yang jelas-jelas publik) seolah itu secret HMAC; verifier yang memilih algoritma dari header akan memvalidasinya. Pertahanan: jangan pernah membiarkan token yang memilih, sematkan algoritma yang diharapkan di sisi verifier, seperti yang sudah dilakukan cek SigningMethodHMAC dan whitelist algorithms=[...] pada kode bagian 05.
Access token + refresh token
Pola ini menyelesaikan kelemahan pencabutan tanpa membuang statelessness. Terbitkan access token berumur pendek (±5-15 menit) yang dipakai di tiap panggilan API, dan refresh token berumur panjang (hari-minggu) yang disimpan aman (cookie HttpOnly, atau penyimpanan aman di mobile). Saat access token kedaluwarsa, klien menukar refresh token dengan pasangan baru. Karena access token berumur pendek, token yang dicuri tak berguna dalam hitungan menit; karena hanya refresh token yang menyentuh store, jalur cepat per-request tetap stateless.
Di tiap refresh, putar: terbitkan refresh token baru dan cabut yang lama. Jika refresh token bekas (yang sudah diputar) muncul lagi, itu sinyal kuat bahwa ia dicuri, cabut seluruh keluarga token dan paksa login ulang. Ini memberi pencabutan praktis sambil menjaga jalur access token bebas pencarian.
Kode: verifikasi RS256 + rotasi refresh
// Verify with a PUBLIC key, pinning RS256 (blocks alg:none & key confusion)
func VerifyRS256(tokenStr string, pub *rsa.PublicKey) (jwt.MapClaims, error) {
tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { // pin family
return nil, jwt.ErrSignatureInvalid
}
return pub, nil
}, jwt.WithValidMethods([]string{"RS256"}))
if err != nil || !tok.Valid {
return nil, err
}
return tok.Claims.(jwt.MapClaims), nil
}
// Rotate a refresh token; detect reuse of an already-spent one.
func Refresh(rdb *redis.Client, presented string) (string, error) {
family, err := rdb.Get(ctx, "rt:"+presented).Result()
if err != nil {
// not a live token, was it a previously-spent one? -> theft
if fam, e := rdb.Get(ctx, "spent:"+presented).Result(); e == nil {
rdb.Del(ctx, "family:"+fam) // revoke the whole family
return "", errors.New("refresh reuse detected")
}
return "", errors.New("invalid refresh token")
}
rdb.Del(ctx, "rt:"+presented) // consume the live token
rdb.Set(ctx, "spent:"+presented, family, 14*24*time.Hour) // remember it
newRefresh := newSessionID()
rdb.Set(ctx, "rt:"+newRefresh, family, 14*24*time.Hour)
return newRefresh, nil
}
# Verify with a PUBLIC key, pinning RS256 (blocks alg:none & key confusion)
def verify_rs256(token: str, public_key) -> dict:
return jwt.decode(token, public_key, algorithms=["RS256"]) # whitelist
# Rotate a refresh token; detect reuse of an already-spent one.
def refresh(rdb, presented: str) -> str:
family = rdb.get(f"rt:{presented}")
if family is None:
# not a live token, was it previously spent? -> theft
fam = rdb.get(f"spent:{presented}")
if fam is not None:
rdb.delete(f"family:{fam.decode()}") # revoke the whole family
raise PermissionError("refresh reuse detected")
raise PermissionError("invalid refresh token")
rdb.delete(f"rt:{presented}") # consume the live token
rdb.setex(f"spent:{presented}", 14 * 24 * 3600, family) # remember it
new_refresh = secrets.token_hex(32)
rdb.setex(f"rt:{new_refresh}", 14 * 24 * 3600, family)
return new_refresh
06 Cookie
Cookie adalah cara menyimpan sepotong informasi, nilai string apa pun, di browser pengguna, dari sisi server. Bagian terakhir itu yang penting: dengan cookie, server bisa menaruh data ke browser klien lewat alur normal request/response HTTP, tunduk pada dua jaminan yang ditegakkan browser:
- Cookie hanya bisa diakses server (domain) yang men-set-nya; satu server tidak bisa melihat cookie server lain. Pembatasan ini fitur keamanan bawaan browser.
- Cookie yang di-set server otomatis menempel di setiap request berikutnya ke server yang sama.
Round-trip otomatis itulah yang membuat cookie begitu berguna untuk auth. Server menyimpan token otorisasi (JWT atau session ID, tergantung implementasi) di cookie; browser (Chrome, Firefox, Safari) mengirimnya kembali di tiap request; server memvalidasinya, mengidentifikasi dan mengotorisasi pengguna. Seluruh tarian "server menyerahkan token ke klien, klien mengembalikannya tiap kali" diotomasi mekanisme cookie, digerakkan sepenuhnya dari sisi server, karena server bisa menulis ke cookie jar klien.
Menandai cookie HttpOnly berarti JavaScript tidak bisa membaca nilainya. Untuk session ID ini penting: ia memblokir satu kelas serangan pencurian token yang bekerja dengan membaca cookie dari skrip halaman. Pasangkan dengan Secure (khusus HTTPS) dan kebijakan SameSite yang masuk akal.
Selami: CSRF dan session fixation
Fitur terbaik cookie, penempelan otomatis ke tiap request, juga akar dari dua serangan khasnya. Keduanya relevan khusus untuk auth berbasis cookie.
Cross-Site Request Forgery (CSRF)
Karena browser mengirim session cookie Anda pada request apa pun ke sebuah domain, termasuk request yang dipicu situs lain, halaman penyerang bisa diam-diam menembakkan request pengubah state ke bank atau aplikasi Anda, dan browser dengan patuhnya menempelkan cookie Anda. Server melihat request terautentikasi penuh yang tak pernah ia maksud untuk dihormati. Perhatikan asimetrinya: auth token-di-header (Bearer JWT) kebal secara alami, karena halaman penyerang tidak bisa membaca atau men-set header Authorization Anda; hanya kredensial yang dibawa cookie yang terkirim otomatis. Pertahanan:
- Cookie SameSite: Lax atau Strict menghentikan cookie ikut pada request lintas-situs. Garis pertahanan pertama.
- Synchronizer token: server menyematkan token acak di halaman/form; request pengubah state wajib menggema-nya kembali. Penyerang tidak bisa membacanya (same-origin policy), jadi mereka tidak bisa memalsukan request valid.
- Double-submit cookie: kirim token CSRF sebagai cookie yang bisa dibaca dan sebagai header/field; server memeriksa keduanya cocok. Stateless dan sederhana.
Session fixation
Di sini penyerang menanam session ID yang sudah ia ketahui, mis. dengan memberi korban tautan berisi sid tetap, atau men-set satu, lalu menunggu korban login dengan ID yang sama itu. Jika server mempertahankan session ID pra-login setelah autentikasi, penyerang kini berbagi sesi terautentikasi. Perbaikannya sederhana dan mutlak: regenerasi session ID di setiap perubahan hak istimewa, terutama tepat setelah login, supaya nilai yang diketahui penyerang jadi tak berguna.
Kode: double-submit CSRF + regenerasi sesi
// Double-submit: compare the CSRF cookie against the header.
func CheckCSRF(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" || r.Method == "HEAD" {
next(w, r); return
}
c, err := r.Cookie("csrf")
hdr := r.Header.Get("X-CSRF-Token")
if err != nil || subtle.ConstantTimeCompare(
[]byte(c.Value), []byte(hdr)) != 1 {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next(w, r)
}
}
// Regenerate the session ID right after a successful login.
func RegenerateSession(oldSID string, u User) (string, error) {
raw, _ := rdb.Get(ctx, "sess:"+oldSID).Result()
rdb.Del(ctx, "sess:"+oldSID) // kill the pre-login ID
newSID := newSessionID() // brand-new value
rdb.Set(ctx, "sess:"+newSID, raw, 15*time.Minute)
return newSID, nil
}
import hmac
# Double-submit: compare the CSRF cookie against the header.
def check_csrf(view):
@wraps(view)
def wrapper(*args, **kwargs):
if request.method in ("GET", "HEAD"):
return view(*args, **kwargs)
cookie = request.cookies.get("csrf", "")
header = request.headers.get("X-CSRF-Token", "")
if not cookie or not hmac.compare_digest(cookie, header):
return jsonify(error="forbidden"), 403
return view(*args, **kwargs)
return wrapper
# Regenerate the session ID right after a successful login.
def regenerate_session(old_sid: str) -> str:
raw = rdb.get(f"sess:{old_sid}")
rdb.delete(f"sess:{old_sid}") # kill the pre-login ID
new_sid = secrets.token_hex(32) # brand-new value
rdb.setex(f"sess:{new_sid}", 15 * 60, raw)
return new_sid
07 Autentikasi Ber-State
Tipe utama pertama. Klien (browser) mengirim username/email dan kata sandi ke server. Server memeriksa validitas dan kebenarannya; jika pengguna memenuhi syarat, ia membuat session ID, menggabungkannya dengan data pengguna, dan menyimpan bundelnya di Redis (DB juga bisa, tapi kebanyakan platform memilih Redis karena aksesnya cepat). Session ID dikirim balik ke klien di dalam cookie HttpOnly: jadi JavaScript tidak bisa membaca session ID. Berkat perilaku auto-attach cookie, setiap request berikutnya membawa cookie itu. Server membaca session ID, memeriksa keberadaan, kedaluwarsa, dan data penggunanya di Redis, mengidentifikasi dan mengotorisasi pengguna, lalu meloloskan request. Session ID bisa berupa string acak kriptografis apa pun, bahkan JWT, tergantung implementasi.
Kode: middleware yang memvalidasi sesi
// RequireSession reads the sid cookie and looks it up in Redis.
func RequireSession(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("sid")
if err != nil {
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
raw, err := rdb.Get(ctx, "sess:"+c.Value).Result()
if err != nil { // missing or expired
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
var u User
json.Unmarshal([]byte(raw), &u)
// attach the user to the request context for later handlers
ctxWithUser := context.WithValue(r.Context(), "user", u)
next(w, r.WithContext(ctxWithUser))
}
}
from functools import wraps
from flask import request, g, jsonify
def require_session(view):
@wraps(view)
def wrapper(*args, **kwargs):
sid = request.cookies.get("sid")
if not sid:
return jsonify(error="authentication failed"), 401
raw = rdb.get(f"sess:{sid}") # None if missing/expired
if raw is None:
return jsonify(error="authentication failed"), 401
g.user = json.loads(raw) # attach for later handlers
return view(*args, **kwargs)
return wrapper
@app.get("/notes")
@require_session
def notes():
return jsonify(owner=g.user["id"])
08 Autentikasi Stateless
Klien mengirim email/username dan kata sandi pada request login. Server memeriksa keasliannya; jika benar, ia membuat JWT bertanda tangan memakai secret key miliknya sendiri (disimpan aman sehingga bisa menandatangani dan memverifikasi). Token menyematkan info pengguna, user ID, role, dll., dan dikembalikan ke klien. Pada semua request berikutnya, klien mengidentifikasi diri dengan mengirim JWT, sesuai konvensi di header Authorization. Server mengekstrak token, memverifikasinya dengan secret, memulihkan user ID, dan setelah sukses menyelesaikan request; kalau tidak, ia mengembalikan 401 Unauthorized atau 403 Forbidden.
Ia disebut stateless justru karena, tidak seperti auth ber-state, tidak ada pencarian store persisten. Semua yang dibutuhkan server tentang pengguna hidup di dalam token bertanda tangan yang mandiri; secret key sendirilah yang membuktikan keaslian token.
Kode: middleware Bearer-token
import "strings"
func RequireJWT(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := r.Header.Get("Authorization")
if !strings.HasPrefix(h, "Bearer ") {
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
claims, err := Verify(strings.TrimPrefix(h, "Bearer "))
if err != nil {
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
// no store lookup, identity comes from the verified token
u := User{ID: claims["sub"].(string), Role: claims["role"].(string)}
next(w, r.WithContext(context.WithValue(r.Context(), "user", u)))
}
}
def require_jwt(view):
@wraps(view)
def wrapper(*args, **kwargs):
h = request.headers.get("Authorization", "")
if not h.startswith("Bearer "):
return jsonify(error="authentication failed"), 401
try:
claims = verify(h.removeprefix("Bearer "))
except jwt.PyJWTError:
return jsonify(error="authentication failed"), 401
# no store lookup, identity comes from the verified token
g.user = {"id": claims["sub"], "role": claims["role"]}
return view(*args, **kwargs)
return wrapper
09 Ber-State vs Stateless: Memilih
Kekurangan yang satu sebagian besar kelebihan yang lain. Pilih berdasarkan dari mana request datang.
| Dimensi | Ber-State (sesi) | Stateless (JWT) |
|---|---|---|
| Kendali atas sesi | Terpusat, pandangan real-time sesi aktif | Tanpa pandangan global |
| Cabut / logout pengguna | Mudah, tinggal buang sesinya | Rumit, valid sampai kedaluwarsa |
| Ketergantungan penyimpanan | Butuh session store | Tidak ada |
| Skalabilitas lintas region | Latensi sinkronisasi, kompleksitas operasi lebih tinggi | Ideal untuk sistem terdistribusi |
| Ramah mobile / API | Cookie kurang cocok | Sangat baik |
| Postur keamanan | Umumnya dianggap lebih aman | Pencurian token = penyamaran |
Dalam praktik, kebanyakan aplikasi sebaiknya condong ber-state: sifat amannya, kemudahan mencabut token dan logout pengguna, serta visibilitas real-time ke sesi aktif sangat berharga. Harganya adalah batas skalabilitas dan kompleksitas operasional lebih tinggi di sistem terdistribusi besar multi-region. Stateless menang saat Anda butuh skalabilitas dan tidak punya ketergantungan session store: API, arsitektur terdistribusi, klien mobile. Satu kelemahan utamanya adalah pencabutan yang sulit.
Anda bisa membagi per klien. Pakai auth ber-state untuk aplikasi web browser utama (tempat Anda ingin kendali dan pencabutan), dan JWT stateless untuk aplikasi mobile dan integrasi server-ke-server pihak ketiga (tempat Anda ingin skalabilitas dan kesederhanaan). Ini mengalahkan kekurangan keduanya.
10 Autentikasi API Key
API key melayani kumpulan kasus pemakaian yang sama sekali berbeda dari auth ber-state/stateless. Alurnya sederhana: di UI sebuah platform Anda klik "generate an API key", menerima string acak kriptografis yang aman secara kriptografis, dan sejak itu memakai string itu untuk akses programatik ke server di balik UI, tanpa pernah menyentuh UI.
Contoh kanonisnya OpenAI. Kebanyakan orang memakai ChatGPT lewat UI-nya: ketik di kotak, dapat respons yang ditampilkan rapi. Tapi di balik UI itu ada banyak server. OpenAI juga menawarkan API key untuk orang yang tidak mau UI, yang ingin kemampuan model di dalam produk atau server mereka sendiri, secara programatik. Untuk memberi akses ke server Anda (bukan UI, bukan platform Anda) kepada kumpulan pengguna berbeda dengan cara yang terbatas, berbasis izin, berbasis kedaluwarsa, Anda menyerahkan API key. Mereka menempelkannya (di header, atau apa pun yang diharapkan server), mengirimkannya kembali, dan bisa melakukan operasi yang diizinkan platform aslinya, tapi lewat kode.
Kenapa tim membangun API key:
- Mudah dibuat: klik tombol di UI, dapat key, pakai.
- Ideal untuk komunikasi machine-to-machine (M2M). Alur biasa kita klien-ke-server: manusia memakai UI (mouse, keyboard), UI mengirim request programatik, server menjalankan logika bisnis dan merespons, UI menampilkannya untuk manusia. M2M menghapus manusia: server saya ingin memakai kemampuan ChatGPT, mis. pengguna minta aplikasi saya "rangkum paragraf ini", dan server saya memanggil provider di belakang layar. Tidak ada UI dan tidak ada interaksi manusia; semuanya dilakukan dalam kode.
Auth ber-state/stateless mengasumsikan pemicu manusia: form login untuk diisi, token untuk disimpan. M2M tidak butuh semua itu, Anda memberi mesin sebuah secret key, ia menyimpannya di environment variable atau store aman, dan mengirimnya di tiap request untuk mengidentifikasi diri. Itulah persis guna API key: interaksi programatik machine-to-machine.
Kode: memverifikasi API key
import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
)
// Store only the HASH of issued keys (like passwords).
// On each call, hash the presented key and constant-time compare.
func hashKey(k string) string {
sum := sha256.Sum256([]byte(k))
return hex.EncodeToString(sum[:])
}
func RequireAPIKey(lookup func(hash string) (*Client, bool)) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-API-Key")
client, ok := lookup(hashKey(key))
// subtle.ConstantTimeCompare avoids timing leaks
if !ok || subtle.ConstantTimeCompare([]byte(key), []byte(key)) != 1 {
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
_ = client // check scopes, quota, expiry here
next(w, r)
}
}
}
import hashlib, hmac
# Store only the HASH of issued keys (like passwords).
def hash_key(k: str) -> str:
return hashlib.sha256(k.encode()).hexdigest()
def require_api_key(view):
@wraps(view)
def wrapper(*args, **kwargs):
key = request.headers.get("X-API-Key", "")
client = lookup_client(hash_key(key)) # your DB lookup
if client is None:
return jsonify(error="authentication failed"), 401
# check scopes, quota, expiry on `client` here
# hmac.compare_digest is constant-time
if not hmac.compare_digest(hash_key(key), client["key_hash"]):
return jsonify(error="authentication failed"), 401
g.client = client
return view(*args, **kwargs)
return wrapper
11 Masalah Delegasi
Sampai di sini pengaturannya sederhana: buat akun dengan username/password per platform, autentikasi, terima token. Tapi semakin banyak platform yang Anda pakai, semakin banyak kredensial yang Anda buat dan harus ingat. Dua masalah muncul:
- Risiko keamanan. Memakai ulang kata sandi sangat umum di era awal (bayangkan 12345 atau password), jadi satu breach mengompromikan banyak akun.
- Kelelahan. Mengelola jumlah akun yang sangat banyak membuat pengguna kewalahan.
Lalu muncul kelas masalah baru: satu situs web butuh akses ke resource situs web lain. Aplikasi travel/pesan tiket perlu memindai Gmail Anda untuk tiket pesawat. Aplikasi sosial ingin mengimpor kontak Anda dari Google atau jaringan lain. Satu platform menginginkan resource platform lain, secara programatik. Inilah masalah delegasi.
Pengguna tinggal berbagi kata sandi. Ini bencana: kata sandi memberi akses penuh ke semua yang ada di akun, tanpa cara membatasi izin, dan tidak mungkin mencabut akses tanpa mengganti kata sandi di mana-mana (karena kata sandi yang sama dipakai ulang lintas situs, dan Anda baru saja memberikannya ke pihak ketiga yang kini bisa melakukan apa saja).
Pada 2007, insinyur dari perusahaan seperti Google dan Twitter berupaya menstandarkan akses yang didelegasikan tanpa berbagi kata sandi. Hasilnya OAuth: Open Authorization, bukan sekadar protokol tapi gagasan revolusioner. Tekniknya: berbagi token, bukan kata sandi. Kata sandi memberi akses total; token memberi akses spesifik dan ter-scope ke bagian tertentu dari akun Anda.
Jika Anda berbagi kata sandi Google dengan saya, saya bisa melihat Photos, riwayat Maps, Kontak Anda, menambah/menghapus event kalender, semua. Tapi jika Anda berbagi token yang ter-scope hanya untuk "membaca kontak", saya bisa membaca kontak Anda dan tidak lebih. Scoping itulah intinya.
12 OAuth 1.0: Empat Peran
OAuth memperkenalkan empat komponen. Ambil contoh: Facebook (aplikasinya) ingin kontak Google Anda.
| Peran | Siapa | Dalam contoh |
|---|---|---|
| Resource owner | Pengguna pemilik data | Anda |
| Client | Aplikasi yang meminta akses | |
| Resource server | Server yang memegang data | Google (akun Anda) |
| Authorization server | Server yang menerbitkan token setelah mengautentikasi pengguna | Server auth Google |
Alur OAuth 1.0
- Client (Facebook) mengarahkan pengguna ke authorization server.
- Pengguna mengautentikasi dan memberi izin: "ya, saya izinkan scope ini".
- Authorization server mengirim token ke client.
- Client memakai token untuk mengakses resource (kontak Anda) di resource server.
Begitulah client mendapat akses ter-scope ke resource platform lain, secara programatik, tanpa Anda pernah berbagi kata sandi, menghapus dengan indah masalah berbagi kata sandi.
13 OAuth 2.0
OAuth 1.0 revolusioner tapi punya keterbatasan: rumit bagi developer untuk diimplementasikan, dan mengandalkan tanda tangan kriptografis yang rawan error. OAuth 2.0 (sekitar 2010) mengatasi keduanya:
- Bearer token. Jauh lebih sederhana daripada menandatangani tiap request. Secara teori lebih rentan, tapi jauh lebih mudah diimplementasikan; "bearer" berarti siapa pun yang memegang token bisa memakainya.
- Alur per jenis aplikasi. Developer memilih alur berdasarkan jenis aplikasinya.
| Alur | Untuk | Catatan |
|---|---|---|
| Authorization Code | Aplikasi sisi server | Alur standar, paling aman |
| Implicit | Aplikasi berbasis browser | Kini tidak disarankan, risikonya keamanan |
| Client Credentials | Machine-to-machine | Server<->server, tanpa pengguna/browser |
| Device Code | Perangkat input terbatas | Mis. Smart TV (tanpa keyboard/mouse) |
Selami: alur Authorization Code dengan PKCE
Alur Authorization Code adalah yang wajib benar-benar dipahami, karena itulah yang dipakai "Sign in with ...". Ia berjalan dalam dua kaki di atas HTTP sungguhan.
Kaki 1: request authorize (front channel, di browser)
Client mengirim pengguna ke authorization server dengan parameter query:
GET /authorize?
response_type=code
&client_id=note_app
&redirect_uri=https://notes.app/callback
&scope=openid%20email%20keep.read
&state=xyz123 # anti-CSRF / anti-mixup nonce
&code_challenge=E9Melh... # = BASE64URL(SHA256(verifier))
&code_challenge_method=S256
Setelah pengguna login dan menyetujui, server mengarahkan kembali: https://notes.app/callback?code=AUTH_CODE&state=xyz123. Client wajib memverifikasi state cocok dengan yang ia kirim, itulah yang menghentikan CSRF/kekacauan pada redirect.
Kaki 2: pertukaran token (back channel, server-ke-server)
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://notes.app/callback
&client_id=note_app
&code_verifier=dBjftJeZ4CVP... # the original random secret
-> 200 OK
{ "access_token": "...", # call the resource server
"refresh_token": "...",
"id_token": "eyJ...", # the OIDC identity JWT (sec 14)
"token_type": "Bearer",
"expires_in": 600 }
Kenapa PKCE?
PKCE (Proof Key for Code Exchange) melindungi public client: single-page app dan aplikasi mobile yang tidak bisa menyimpan rahasia. Client membuat codeverifier acak, hanya mengirim hash-nya (codechallenge) di kaki 1, dan mengungkap verifier mentahnya di kaki 2. Jika penyerang mencegat authorization code (mis. aplikasi jahat menangkap redirect mobile), code itu tak berguna tanpa verifier, yang tidak pernah meninggalkan client yang sah. PKCE-lah alasan alur Implicit kini tidak disarankan: Authorization Code + PKCE memberi aplikasi browser/mobile keamanan yang sama dengan client sisi server yang konfidensial.
Kode: nilai PKCE + pertukaran token
// Build the PKCE pair before redirecting the user.
func newPKCE() (verifier, challenge string) {
b := make([]byte, 32)
rand.Read(b)
verifier = base64.RawURLEncoding.EncodeToString(b)
sum := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(sum[:])
return
}
// Leg 2: exchange the code (send code_verifier, not the secret).
func exchange(code, verifier string) (*TokenResp, error) {
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {"https://notes.app/callback"},
"client_id": {"note_app"},
"code_verifier": {verifier},
}
resp, err := http.PostForm("https://auth.example/token", form)
if err != nil { return nil, err }
defer resp.Body.Close()
var t TokenResp
json.NewDecoder(resp.Body).Decode(&t)
return &t, nil
}
import base64, hashlib, secrets, requests
# Build the PKCE pair before redirecting the user.
def new_pkce():
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
# Leg 2: exchange the code (send code_verifier, not the secret).
def exchange(code: str, verifier: str) -> dict:
resp = requests.post("https://auth.example/token", data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://notes.app/callback",
"client_id": "note_app",
"code_verifier": verifier,
})
resp.raise_for_status()
return resp.json() # access_token, refresh_token, id_token, ...
OAuth sangat baik untuk otorisasi (mendelegasikan apa yang boleh dilakukan client) tapi ia tidak memecahkan autentikasi (membuktikan siapa penggunanya). Ingat: autentikasi = identitas (siapa Anda, nama Anda, ID Anda); otorisasi = izin (apa yang bisa Anda lakukan). OAuth memecahkan yang terakhir lewat delegasi, bukan yang pertama. Celah itulah yang diisi OpenID Connect.
14 OpenID Connect (OIDC)
Sekitar 2014, OpenID Connect (OIDC) dibangun di atas mekanisme keamanan OAuth 2.0 untuk mengisi celah autentikasi. Tambahan intinya adalah ID token: biasanya JWT (persis inilah kenapa JWT pantas dipahami lebih awal; ia muncul kembali di mana-mana).
ID token membawa info identitas: user ID, kapan ia login (field iat), otoritas penerbit (platform mana yang menerbitkan token), dan sering nama serta email pengguna, apa pun yang dibutuhkan relying party. Berkat OIDC, praktis setiap platform kini menawarkan "Sign in with Google / Facebook / Discord." Saat Anda memakainya, OIDC bekerja di balik layar untuk mengambil identitas Anda (email, foto profil, nama) dari Google, dan client menyimpannya atau sekadar memakainya untuk mengautentikasi Anda, tanpa membangun sistem auth sendiri. OIDC menambah lapisan identifikasi di atas OAuth 2.0.
Alur OIDC
Anggap Anda mengunjungi aplikasi pencatat dan mengklik "Sign in with Google".
- Client (aplikasi catatan) mengarahkan Anda ke authorization server.
- Anda login di authorization server Google (bukan milik client) dan memberi izin yang diminta (baca email / nama / foto profil, dll.).
- Authorization server mengembalikan authorization code dan ID token ke client.
- Client menukar authorization code dengan resource server untuk mendapatkan access token (dan ID token jika belum dapat).
- ID token (sebuah JWT) membawa identitas pengguna (ID, nama, nilai); access token memungkinkan aplikasi catatan melakukan operasi di resource server atas nama Anda, mis. membaca semua catatan Anda dari Google Keep, tapi hanya scope yang Anda beri.
Bersama-sama, OAuth 2.0 dan OpenID Connect bertindak seperti satpam / pembuat kunci era digital: mereka memastikan tidak ada, baik pengguna maupun platform, yang mendapat akses lebih dari yang dibutuhkan atau diminta. Mereka mengubah internet dari kekacauan berbagi kata sandi menjadi sistem yang aman dan saling terhubung, sebab itu kita kini bisa menyambungkan satu platform ke platform lain, berbagi resource dan izin dengan aman, dan menawarkan login satu klik.
15 Memilih Jenis Auth
Aturan praktis untuk empat tipe utama yang harus diketahui setiap engineer backend:
| Tipe | Mekanisme | Pakai untuk |
|---|---|---|
| Ber-State | Session ID / JWT + store persisten | Alur auth aplikasi web, sebagian besar model SaaS, tempat data sesi khusus pengguna tinggal di server |
| Stateless | Token bertanda tangan mandiri | API dan sistem terdistribusi yang berskala, tempat token membawa info pengguna |
| OAuth / OIDC | Token yang didelegasikan + ID token | Integrasi pihak ketiga dan "login via Google / Facebook / Discord" |
| API key | String secret yang dibagikan di awal | Server-to-server / machine-to-machine, atau akses klien tujuan tunggal ke API |
Dalam kerja backend sehari-hari membangun API, yang paling sering Anda raih adalah auth ber-state dan stateless.
16 Otorisasi & RBAC
Otorisasi tidak seluas autentikasi, tapi gagasan tingkat tingginya penting. Setelah pengguna terautentikasi (mis. ke platform pencatat), mereka bisa membuat, menghapus, dan memperbarui catatan. Sekarang anggap fitur hapus Anda tidak menghapus permanen, ia memindahkan catatan ke keranjang sampah, dan setelah 30 hari mereka pindah ke zona mati (hilang bagi pengguna, tapi tidak dihapus dari DB). Sebagai pembuat, Anda ingin admin UI terpisah dengan kemampuan khusus yang tidak dimiliki pengguna biasa, seperti mengelola catatan zona mati.
Perbaikan naif adalah mengirim string acak rahasia dengan tiap panggilan API admin supaya server memberi akses yang ditinggikan. Dua masalah: (1) jika ada yang mencegat string itu, mereka bisa menghapus DB atau mengacaukan data pengguna lain, risiko keamanan besar; (2) untuk memberi akses yang sama ke teman/pemelihara tepercaya, Anda harus berbagi string atau menciptakan string lain dan mengkhususkan semuanya di sisi server. Sistemnya cepat jadi rumit, sulit dikelola, dan makin rawan cacat.
Kebutuhan umum, memberi izin spesifik ke pengguna spesifik: persis itulah yang dipecahkan otorisasi. Tidak semua pengguna punya akses yang sama; sebagian lebih banyak, sebagian lebih sedikit, sebagian kemampuan yang sama sekali berbeda. Dalam aplikasi multi-tenant, admin org mungkin menetapkan read atau read/write ke anggota yang berbeda. Model paling terkenalnya RBAC, Role-Based Access Control.
Cara kerja RBAC
Definisikan role (user, admin, moderator, atau role kustom Anda sendiri), dan beri tiap role satu set izin atas resource. Anda bisa segranular apa pun yang Anda suka.
Alur runtime-nya: pengguna mendaftar dan server menetapkan role (user, admin, ...). Pada tiap request berikutnya pengguna mengirim identifikasinya (session ID untuk ber-state, atau JWT untuk stateless). Di awal siklus request server menyimpulkan role, dari klaim token atau lewat pencarian DB, dan menempelkannya supaya middleware/logika berikutnya bisa memutuskan akses. Jika role-nya admin, endpoint zona mati diizinkan; jika role-nya user, server mengembalikan 403 Forbidden: artinya Anda tidak punya cukup izin untuk resource ini.
Kode: middleware RBAC
// RequireRole runs AFTER an auth middleware that set "user".
func RequireRole(roles ...string) func(http.HandlerFunc) http.HandlerFunc {
allowed := map[string]bool{}
for _, r := range roles {
allowed[r] = true
}
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, _ := r.Context().Value("user").(User)
if !allowed[u.Role] {
http.Error(w, "forbidden", http.StatusForbidden) // 403
return
}
next(w, r)
}
}
}
// usage: only admins reach the dead-zone handler
// mux.Handle("/admin/deadzone",
// RequireJWT(RequireRole("admin")(deadZoneHandler)))
# require_role runs AFTER an auth decorator that set g.user
def require_role(*roles):
allowed = set(roles)
def deco(view):
@wraps(view)
def wrapper(*args, **kwargs):
if g.user["role"] not in allowed:
return jsonify(error="forbidden"), 403 # 403
return view(*args, **kwargs)
return wrapper
return deco
# usage: only admins reach the dead-zone handler
@app.get("/admin/deadzone")
@require_jwt
@require_role("admin")
def dead_zone():
return jsonify(notes=[...])
Selami: hirarki role, ABAC, dan seterusnya
Di mana RBAC polos mulai renggang: ledakan role
RBAC bersih sampai kebutuhan menjadi kondisional. Saat Anda butuh "editor, tapi hanya untuk dokumen di departemen mereka sendiri, dan hanya saat jam kerja", itu tidak bisa diekspresikan dengan nama role saja, jadi tim menciptakan editordeptfinance, editordeptlegal, dan seterusnya. Inilah ledakan role: jumlah role menggembung karena role hanya bisa meng-encode siapa, bukan resource mana atau dalam kondisi apa.
Hirarki role / pewarisan
Katup pelepas pertama adalah membuat role mewarisi: admin mencakup semua yang bisa moderator, yang mencakup semua yang bisa user. Anda mendefinisikan izin sekali di role terendah dan membiarkan role lebih tinggi mengakumulasikannya, alih-alih mendaftar ulang. Ini menjinakkan duplikasi tapi tidak menambah kondisi.
ABAC: Attribute-Based Access Control
ABAC memutuskan akses dengan mengevaluasi atribut terhadap kebijakan, lintas empat dimensi: subjek (role, departemen, izin tingkat tinggi), resource (pemilik, klasifikasi, departemen), aksi (read/write/delete), dan lingkungan (waktu, IP, perangkat). Satu kebijakan, "pengguna boleh mengedit dokumen jika ia pemiliknya atau berbagi departemen, dan dokumennya tidak diarsipkan", menggantikan lusinan role buatan. ABAC jauh lebih ekspresif; komprominya adalah kebijakan lebih sulit diaudit ("siapa yang bisa menyentuh ini?" tidak terjawab dengan memindai daftar role).
| Model | Memutuskan berdasarkan | Kekuatan / kelemahan |
|---|---|---|
| RBAC | Role yang Anda pegang | Sederhana, mudah diaudit / ledakan role saat kondisional |
| ABAC | Atribut + kebijakan (subjek/resource/aksi/env) | Sangat ekspresif / lebih sulit diaudit & dinalar |
| ReBAC | Relasi (owner-of, member-of, parent-of) | Hebat untuk grafik berbagi (gaya Google Drive) / butuh store relasi, mis. Google Zanzibar |
Banyak sistem nyata adalah hibrida: RBAC untuk role kasar, kebijakan ABAC untuk sisi kondisional, dan ReBAC saat akses mengikuti grafik berbagi.
Kode: evaluator kebijakan ABAC kecil
type Subject struct{ ID, Dept, Role string }
type Resource struct{ OwnerID, Dept string; Archived bool }
// Policy: can `s` perform `action` on `r`, given the environment?
func CanEdit(s Subject, r Resource, hour int) bool {
if r.Archived {
return false
}
sameDept := s.Dept == r.Dept
owns := s.ID == r.OwnerID
businessHours := hour >= 9 && hour < 18
// owner OR same-department editor, only in business hours
return (owns || (sameDept && s.Role == "editor")) && businessHours
}
// usage inside a handler, after auth has populated the subject:
// if !CanEdit(subj, doc, time.Now().Hour()) {
// http.Error(w, "forbidden", http.StatusForbidden)
// }
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Subject: id: str; dept: str; role: str
@dataclass
class Resource: owner_id: str; dept: str; archived: bool
# Policy: can `s` perform the action on `r`, given the environment?
def can_edit(s: Subject, r: Resource, hour: int) -> bool:
if r.archived:
return False
owns = s.id == r.owner_id
same_dept = s.dept == r.dept
business_hours = 9 <= hour < 18
# owner OR same-department editor, only in business hours
return (owns or (same_dept and s.role == "editor")) and business_hours
# usage inside a view, after auth populated the subject:
# if not can_edit(subj, doc, datetime.now().hour):
# return jsonify(error="forbidden"), 403
17 Pesan Error & Serangan Timing
Pesan error generik
Saat auth, Anda tergoda mengirim pesan membantu: "user not found," "incorrect password," "account locked due to too many failed attempts." Ini membantu pengguna yang sah, tapi memberi penyerang petunjuk. "User not found" memberi tahu penyerang username itu tidak ada, jadi mereka pindah ke yang berikutnya. "Incorrect password" mengonfirmasi username-nya valid, jadi mereka memusatkan serangan brute-force/kamus hanya pada kata sandi, memperluas permukaan serangan.
Dalam alur autentikasi, selalu kirim satu pesan generik: mis. autentikasi gagal, untuk setiap kasus kegagalan, supaya penyerang tidak bisa tahu langkah mana yang gagal atau merencanakan langkah berikutnya. Pesan ramah pengguna boleh di tempat lain (validasi, API lain), tapi tidak di sini.
Serangan timing
Bahkan dengan pesan generik, waktu yang diambil sebuah respons bisa membocorkan informasi. Login khas melakukan tiga langkah berurutan:
Kata sandi disimpan sebagai hash (tak pernah teks polos): saat pendaftaran kata sandi di-hash menjadi string aman kriptografis; saat login server men-hash kata sandi yang diberikan dengan cara yang sama dan membandingkannya dengan hash tersimpan. Jebakannya: jika username tidak valid, sistem berhenti di langkah 1 dan membalas cepat. Jika username valid tapi kata sandi salah, ia mencapai langkah 3 dan lebih lambat, karena hashing memakan waktu lebih lama daripada pemeriksaan awal. Dari jeda itu, katakanlah 200 ms, penyerang bisa menyimpulkan apakah username ada dan di langkah mana auth gagal, lalu menyusun strategi.
1. Perbandingan waktu konstan. Pakai fungsi perbandingan waktu-konstan yang aman secara kriptografis untuk hash, yang waktu eksekusinya tidak berubah dengan kemiripan input. 2. Samakan/simulasikan jeda. Bahkan saat username tidak ada, jangan membalas seketika, simulasikan jeda (Node: setTimeout; Go: time.Sleep) supaya penyerang tidak bisa membedakan kegagalan username dari kegagalan kata sandi.
Kode: pesan generik + waktu yang disamakan
// dummyHash: a fixed, precomputed bcrypt hash, used to equalize timing.
var dummyHash = "$2a$12$abcdefghijklmnopqrstuv0000000000000000000000000000000000"
func Authenticate(w http.ResponseWriter, email, pw string) {
start := time.Now()
const floor = 250 * time.Millisecond // equalize every path
defer func() { // pad so all outcomes take ~the same time
if d := time.Since(start); d < floor {
time.Sleep(floor - d)
}
}()
user, found := lookupByEmail(email)
if !found {
// still run a dummy hash so step-3 cost is paid anyway
bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(pw))
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
// CompareHashAndPassword is constant-time internally
if bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(pw)) != nil {
http.Error(w, "authentication failed", http.StatusUnauthorized)
return
}
// success: issue session / JWT ...
}
import time
DUMMY_HASH = bcrypt.hashpw(b"x", bcrypt.gensalt())
FLOOR = 0.25 # seconds, equalize every path
def authenticate(email: str, pw: str):
start = time.perf_counter()
try:
user = lookup_by_email(email)
if user is None:
# run a dummy hash so timing matches the valid path
bcrypt.checkpw(pw.encode(), DUMMY_HASH)
return None, "authentication failed"
if not bcrypt.checkpw(pw.encode(), user["hash"]): # constant-time
return None, "authentication failed"
return user, None # success: issue session / JWT
finally:
# pad so all outcomes take ~the same wall-clock time
elapsed = time.perf_counter() - start
if elapsed < FLOOR:
time.sleep(FLOOR - elapsed)
18 Crib-Sheet Debug
Seluruh manual, dipadatkan menjadi yang Anda raih saat tertekan.
| Konsep | Satu baris |
|---|---|
| Autentikasi | Siapa Anda?, menetapkan identitas dalam konteks. |
| Otorisasi | Apa yang bisa Anda lakukan?, izin dalam konteks itu. |
| Faktor | Sesuatu yang Anda tahu / miliki / adalah (gabungkan untuk MFA). |
| Hashing | Satu arah, panjang tetap, deterministik. Simpan hash-nya, jangan pernah kata sandinya. |
| Sesi | Memori sisi server; cookie hanya memegang session ID. |
| JWT | header.payload.signature; mandiri, stateless, ditandatangani dengan secret. |
| Cookie | Penyimpanan browser yang di-set server; terkirim otomatis per request; pakai HttpOnly + Secure + SameSite. |
| Ber-State | Pencarian store tiap request; mudah dicabut; lebih sulit berskala. |
| Stateless | Tanpa store; berskala hebat; pencabutan adalah titik lemah. |
| API key | Secret yang dibagikan di awal untuk machine-to-machine; simpan hash-nya. |
| OAuth 2.0 | Otorisasi ter-delegasi dan ter-scope via bearer token. |
| OIDC | Menambah ID token (JWT) di atas OAuth untuk autentikasi. |
| RBAC | Role -> izin -> resource. 403 Forbidden saat kurang. |
| Higiene error | Satu pesan "autentikasi gagal" generik untuk semua kegagalan. |
| Timing | Perbandingan waktu konstan + samakan waktu respons. |
| Tips produksi | Bikin sendiri untuk belajar; pakai auth provider untuk rilis. |